agentmux_srv\backend\history/
claude_adapter.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Claude Code history adapter.
5//! Scans ~/.claude/projects/ and ~/.config/claude-*/projects/ for session JSONL files.
6
7use std::fs;
8use std::io::{BufRead, BufReader};
9use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use super::adapter::*;
13
14pub struct ClaudeHistoryAdapter {
15    /// All base directories to scan for project folders.
16    base_dirs: Vec<PathBuf>,
17}
18
19impl ClaudeHistoryAdapter {
20    pub fn new() -> Self {
21        let mut base_dirs = Vec::new();
22
23        if let Some(home) = dirs::home_dir() {
24            // User's personal Claude sessions
25            let personal = home.join(".claude").join("projects");
26            if personal.is_dir() {
27                base_dirs.push(personal);
28            }
29
30            // AgentMux agent sessions: ~/.config/claude-*/projects/
31            let config_dir = home.join(".config");
32            if config_dir.is_dir() {
33                if let Ok(entries) = fs::read_dir(&config_dir) {
34                    for entry in entries.flatten() {
35                        let name = entry.file_name();
36                        let name_str = name.to_string_lossy();
37                        if name_str.starts_with("claude-") {
38                            let projects = entry.path().join("projects");
39                            if projects.is_dir() {
40                                base_dirs.push(projects);
41                            }
42                        }
43                    }
44                }
45            }
46        }
47
48        ClaudeHistoryAdapter { base_dirs }
49    }
50
51    /// Count subagent JSONL files in a session's subagents/ directory.
52    fn count_subagents(session_dir: &Path) -> u32 {
53        let subagents_dir = session_dir.join("subagents");
54        if !subagents_dir.is_dir() {
55            return 0;
56        }
57        fs::read_dir(&subagents_dir)
58            .map(|entries| {
59                entries
60                    .flatten()
61                    .filter(|e| {
62                        let name = e.file_name();
63                        let s = name.to_string_lossy();
64                        s.starts_with("agent-") && s.ends_with(".jsonl")
65                    })
66                    .count() as u32
67            })
68            .unwrap_or(0)
69    }
70
71    /// Decode a project directory name back to a path.
72    /// e.g., "C--Users-asafe--claw-agentx-workspace" → "C:/Users/asafe/.claw/agentx-workspace"
73    /// This is lossy — real hyphens are indistinguishable from path separators.
74    fn decode_project_path(encoded: &str) -> String {
75        // Best-effort: replace leading drive pattern and path separators
76        let mut result = encoded.to_string();
77        // Restore drive letter colon: "C-" at start → "C:"
78        if result.len() >= 2 && result.as_bytes()[1] == b'-' && result.as_bytes()[0].is_ascii_uppercase() {
79            result = format!("{}:{}", &result[..1], &result[2..]);
80        }
81        // Replace remaining hyphens with forward slashes
82        result = result.replace('-', "/");
83        result
84    }
85}
86
87impl HistoryAdapter for ClaudeHistoryAdapter {
88    fn provider(&self) -> &str {
89        "claude"
90    }
91
92    fn discover_files(&self) -> Result<Vec<DiscoveredFile>, HistoryError> {
93        let mut files = Vec::new();
94
95        for base_dir in &self.base_dirs {
96            let entries = match fs::read_dir(base_dir) {
97                Ok(e) => e,
98                Err(_) => continue,
99            };
100
101            for project_entry in entries.flatten() {
102                let project_path = project_entry.path();
103                if !project_path.is_dir() {
104                    // Top-level .jsonl files (session files at project root level)
105                    if project_path.extension().map_or(false, |e| e == "jsonl") {
106                        if let Ok(meta) = project_path.metadata() {
107                            let mtime = meta
108                                .modified()
109                                .ok()
110                                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
111                                .map(|d| d.as_millis() as i64)
112                                .unwrap_or(0);
113                            files.push(DiscoveredFile {
114                                file_path: project_path.to_string_lossy().into(),
115                                mtime_ms: mtime,
116                            });
117                        }
118                    }
119                    continue;
120                }
121
122                // Scan for .jsonl files inside project directories
123                // These are session directories that may also contain subagents/
124                let dir_entries = match fs::read_dir(&project_path) {
125                    Ok(e) => e,
126                    Err(_) => continue,
127                };
128                for file_entry in dir_entries.flatten() {
129                    let file_path = file_entry.path();
130                    if file_path.extension().map_or(false, |e| e == "jsonl") {
131                        // Skip subagent files — those are children of sessions
132                        if file_path
133                            .parent()
134                            .and_then(|p| p.file_name())
135                            .map_or(false, |n| n == "subagents")
136                        {
137                            continue;
138                        }
139                        if let Ok(meta) = file_path.metadata() {
140                            let mtime = meta
141                                .modified()
142                                .ok()
143                                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
144                                .map(|d| d.as_millis() as i64)
145                                .unwrap_or(0);
146                            files.push(DiscoveredFile {
147                                file_path: file_path.to_string_lossy().into(),
148                                mtime_ms: mtime,
149                            });
150                        }
151                    }
152                }
153            }
154        }
155
156        files.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms));
157        Ok(files)
158    }
159
160    fn extract_meta(&self, file_path: &str) -> Result<Option<SessionMeta>, HistoryError> {
161        let path = Path::new(file_path);
162        let file = fs::File::open(path)?;
163        let file_size = file.metadata()?.len();
164        let reader = BufReader::new(file);
165
166        let mut first_user_msg = String::new();
167        let mut model = "unknown".to_string();
168        let mut slug = String::new();
169        let mut cwd = String::new();
170        let mut git_branch = String::new();
171        let mut entry_count = 0u32;
172        let mut total_tokens: u64 = 0;
173        let mut first_timestamp: i64 = 0;
174        let mut last_timestamp: i64 = 0;
175        let mut session_id = String::new();
176
177        // Extract session_id from filename (stem)
178        if let Some(stem) = path.file_stem() {
179            session_id = stem.to_string_lossy().into();
180        }
181
182        let mut lines_iter = reader.lines();
183        let mut found_all_meta = false;
184
185        while let Some(Ok(line)) = lines_iter.next() {
186            if line.trim().is_empty() {
187                continue;
188            }
189
190            let entry: serde_json::Value = match serde_json::from_str(&line) {
191                Ok(v) => v,
192                Err(_) => continue,
193            };
194            entry_count += 1;
195
196            // Extract timestamp
197            if let Some(ts_str) = entry.get("timestamp").and_then(|v| v.as_str()) {
198                if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
199                    let ts = dt.timestamp_millis();
200                    if first_timestamp == 0 {
201                        first_timestamp = ts;
202                    }
203                    last_timestamp = ts;
204                }
205            }
206
207            // Extract session slug
208            if slug.is_empty() {
209                if let Some(s) = entry.get("slug").and_then(|v| v.as_str()) {
210                    slug = s.to_string();
211                }
212            }
213
214            // Extract session ID from entry if available
215            if session_id.is_empty() {
216                if let Some(s) = entry.get("sessionId").and_then(|v| v.as_str()) {
217                    session_id = s.to_string();
218                }
219            }
220
221            // Extract cwd
222            if cwd.is_empty() {
223                if let Some(c) = entry.get("cwd").and_then(|v| v.as_str()) {
224                    cwd = c.to_string();
225                }
226            }
227
228            // Extract git branch
229            if git_branch.is_empty() {
230                if let Some(b) = entry.get("gitBranch").and_then(|v| v.as_str()) {
231                    git_branch = b.to_string();
232                }
233            }
234
235            let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
236
237            // Extract model from first assistant entry
238            if model == "unknown" && entry_type == "assistant" {
239                if let Some(m) = entry.pointer("/message/model").and_then(|v| v.as_str()) {
240                    model = m.to_string();
241                }
242                // Accumulate tokens
243                if let Some(usage) = entry.pointer("/message/usage") {
244                    if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
245                        total_tokens += out;
246                    }
247                }
248            } else if entry_type == "assistant" {
249                // Still accumulate tokens for non-first assistant entries
250                if let Some(usage) = entry.pointer("/message/usage") {
251                    if let Some(out) = usage.get("output_tokens").and_then(|v| v.as_u64()) {
252                        total_tokens += out;
253                    }
254                }
255            }
256
257            // Extract first user message for preview
258            if first_user_msg.is_empty() && entry_type == "user" {
259                if let Some(content) = entry.pointer("/message/content") {
260                    if let Some(text) = content.as_str() {
261                        first_user_msg = text.chars().take(200).collect();
262                    } else if let Some(arr) = content.as_array() {
263                        // Content can be an array of content blocks
264                        for block in arr {
265                            if block.get("type").and_then(|v| v.as_str()) == Some("text") {
266                                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
267                                    first_user_msg = text.chars().take(200).collect();
268                                    break;
269                                }
270                            }
271                        }
272                    }
273                }
274            }
275
276            // Early exit: once we have all metadata fields, count remaining lines cheaply
277            if !first_user_msg.is_empty()
278                && model != "unknown"
279                && !cwd.is_empty()
280                && !slug.is_empty()
281            {
282                found_all_meta = true;
283                break;
284            }
285        }
286
287        // Count remaining lines without parsing JSON (fast)
288        if found_all_meta {
289            for remaining_line in lines_iter {
290                if let Ok(line) = remaining_line {
291                    if !line.trim().is_empty() {
292                        entry_count += 1;
293                    }
294                }
295            }
296        }
297
298        if entry_count == 0 {
299            return Ok(None);
300        }
301
302        // Fallback: decode project path from parent directory name
303        if cwd.is_empty() {
304            if let Some(parent_name) = path
305                .parent()
306                .and_then(|p| p.file_name())
307                .map(|n| n.to_string_lossy().to_string())
308            {
309                cwd = Self::decode_project_path(&parent_name);
310            }
311        }
312
313        // Count subagents
314        let subagent_count = if let Some(parent) = path.parent() {
315            let session_dir = parent.join(&session_id);
316            Self::count_subagents(&session_dir)
317        } else {
318            0
319        };
320
321        let file_meta = fs::metadata(file_path)?;
322        let modified_at = file_meta
323            .modified()
324            .ok()
325            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
326            .map(|d| d.as_millis() as i64)
327            .unwrap_or(last_timestamp);
328
329        Ok(Some(SessionMeta {
330            session_id,
331            file_path: file_path.to_string(),
332            provider: "claude".to_string(),
333            model,
334            slug,
335            working_directory: cwd,
336            created_at: first_timestamp,
337            modified_at,
338            message_count: entry_count,
339            first_user_message: first_user_msg,
340            file_size_bytes: file_size,
341            git_branch,
342            total_tokens,
343            subagent_count,
344        }))
345    }
346
347    fn parse_file(&self, file_path: &str) -> Result<Option<HistorySession>, HistoryError> {
348        // First extract meta
349        let meta = match self.extract_meta(file_path)? {
350            Some(m) => m,
351            None => return Ok(None),
352        };
353
354        let file = fs::File::open(file_path)?;
355        let reader = BufReader::new(file);
356        let mut messages = Vec::new();
357
358        for line in reader.lines() {
359            let line = match line {
360                Ok(l) => l,
361                Err(_) => continue,
362            };
363            if line.trim().is_empty() {
364                continue;
365            }
366
367            let entry: serde_json::Value = match serde_json::from_str(&line) {
368                Ok(v) => v,
369                Err(_) => continue,
370            };
371
372            let entry_type = entry.get("type").and_then(|v| v.as_str()).unwrap_or("");
373
374            // Extract timestamp
375            let timestamp = entry
376                .get("timestamp")
377                .and_then(|v| v.as_str())
378                .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
379                .map(|dt| dt.timestamp_millis())
380                .unwrap_or(0);
381
382            if entry_type == "user" {
383                let content = if let Some(msg) = entry.pointer("/message/content") {
384                    if let Some(text) = msg.as_str() {
385                        text.to_string()
386                    } else if let Some(arr) = msg.as_array() {
387                        arr.iter()
388                            .filter_map(|block| {
389                                if block.get("type").and_then(|v| v.as_str()) == Some("text") {
390                                    block.get("text").and_then(|v| v.as_str()).map(String::from)
391                                } else {
392                                    None
393                                }
394                            })
395                            .collect::<Vec<_>>()
396                            .join("\n")
397                    } else {
398                        String::new()
399                    }
400                } else {
401                    String::new()
402                };
403
404                if !content.is_empty() {
405                    messages.push(HistoryMessage {
406                        role: "user".to_string(),
407                        content,
408                        timestamp,
409                        tool_uses: vec![],
410                    });
411                }
412            } else if entry_type == "assistant" {
413                let mut text_parts = Vec::new();
414                let mut tool_uses = Vec::new();
415
416                if let Some(content_arr) = entry.pointer("/message/content").and_then(|v| v.as_array()) {
417                    for block in content_arr {
418                        let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
419                        match block_type {
420                            "text" => {
421                                if let Some(text) = block.get("text").and_then(|v| v.as_str()) {
422                                    text_parts.push(text.to_string());
423                                }
424                            }
425                            "tool_use" => {
426                                let name = block
427                                    .get("name")
428                                    .and_then(|v| v.as_str())
429                                    .unwrap_or("unknown")
430                                    .to_string();
431                                // Summarize first argument
432                                let arg_summary = if let Some(input) = block.get("input") {
433                                    if let Some(obj) = input.as_object() {
434                                        // Take first key-value pair as summary
435                                        obj.iter()
436                                            .next()
437                                            .map(|(k, v)| {
438                                                let val_str = if let Some(s) = v.as_str() {
439                                                    s.chars().take(100).collect::<String>()
440                                                } else {
441                                                    v.to_string().chars().take(100).collect::<String>()
442                                                };
443                                                format!("{}: {}", k, val_str)
444                                            })
445                                            .unwrap_or_default()
446                                    } else {
447                                        String::new()
448                                    }
449                                } else {
450                                    String::new()
451                                };
452                                tool_uses.push(ToolUseSummary {
453                                    name,
454                                    argument_summary: arg_summary,
455                                });
456                            }
457                            // Skip "thinking" blocks — they're internal reasoning
458                            _ => {}
459                        }
460                    }
461                }
462
463                let content = text_parts.join("\n");
464                if !content.is_empty() || !tool_uses.is_empty() {
465                    messages.push(HistoryMessage {
466                        role: "assistant".to_string(),
467                        content,
468                        timestamp,
469                        tool_uses,
470                    });
471                }
472            }
473        }
474
475        Ok(Some(HistorySession { meta, messages }))
476    }
477}